Robust and Modular Exception Handling in NestJS {chatgpt}

ChatGPT Deep Research answer

Introduction

Building a robust error-handling system in NestJS involves more than just throwing generic exceptions. A well-designed approach ensures that errors are consistently formatted, correctly mapped to HTTP responses, and centrally logged. This is especially important in large modular or monorepo architectures, where multiple modules or services need a unified error strategy. NestJS provides an exceptions layer that can be extended to meet these needs. By leveraging custom exception classes, global exception filters, and integrations with tools like Prisma and Sentry, we can create a scalable error-handling pattern that improves maintainability and observability.

Custom Exception Class with Hierarchical Error Codes

Instead of scattering HttpException instances throughout your code, define a custom exception class (e.g., RacketimeException) to represent application-specific errors in a structured way. This class can encapsulate a module name, a symbolic error code, and a payload for additional context. By mapping these symbolic identifiers to detailed error definitions, you achieve hierarchical or modular error codes (each module has its own namespace of errors).

Key benefits:

Implementing the class: The custom exception should extend NestJS’s HttpException so it integrates with Nest’s response flow. It can look up an error definition from a mapping. For example:

import { HttpException, HttpStatus } from '@nestjs/common';

interface ErrorInfo { 
  status: number; 
  message: string; 
}

const ERROR_DEFINITIONS: Record<string, Record<string, ErrorInfo>> = {
  // Module-specific error definitions
  Auth: {
    INVALID_CREDENTIALS: { status: HttpStatus.UNAUTHORIZED, message: 'Invalid credentials provided.' },
    USER_NOT_FOUND:     { status: HttpStatus.NOT_FOUND, message: 'User not found.' },
    // ...other Auth module errors
  },
  Payment: {
    INSUFFICIENT_FUNDS: { status: HttpStatus.BAD_REQUEST, message: 'Insufficient funds for transaction.' },
    // ...other Payment module errors
  },
  // ...other modules
};

export class RacketimeException extends HttpException {
  /** 
   * @param module     Name of the module (e.g. 'Auth', 'Payment') 
   * @param code       Symbolic error code (e.g. 'USER_NOT_FOUND') 
   * @param payload    Additional details (optional, used to enrich the message or log context) 
   */
  constructor(module: string, code: string, payload?: any) {
    const moduleErrors = ERROR_DEFINITIONS[module] || {};
    const errorInfo = moduleErrors[code];

    if (!errorInfo) {
      // Fallback for undefined errors
      super({ 
        statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
        message: `Unhandled error ${module}.${code}`, 
        error: 'InternalServerError' 
      }, HttpStatus.INTERNAL_SERVER_ERROR);
    } else {
      // You can incorporate payload data into the message if needed
      const { status, message } = errorInfo;
      // Include a combined error code (e.g. "Auth.USER_NOT_FOUND") in the response for easy identification
      const errorCode = `${module}.${code}`;
      super({ statusCode: status, error: errorCode, message, ...(payload && { details: payload }) }, status);
    }
  }
}

In this design, the ERROR_DEFINITIONS acts as a registry mapping each module’s error symbols to an HTTP status and a default message. The RacketimeException constructor uses this to construct a standardized response object which includes the HTTP status code, a combined error code string, and a message. The optional payload can carry extra context (e.g. an ID that was not found) and can be attached in a details field for logging or debugging (ensure not to leak sensitive info).

Usage example: If a user lookup fails in the Auth module, you might throw:

throw new RacketimeException('Auth', 'USER_NOT_FOUND', { userId });

This will produce a 404 Not Found response with a JSON body similar to:

{
  "statusCode": 404,
  "error": "Auth.USER_NOT_FOUND",
  "message": "User not found.",
  "details": { "userId": "12345" }
}

Such structured errors make it easy for clients to parse the response and for developers to trace the origin. You can also create module-specific subclasses of RacketimeException for convenience. For example, an AuthException class could preset the module name so you only pass the code and payload. This pattern scales well in a monorepo: each module can define its error codes (perhaps in a separate file or enum), and all use the common base class for consistency.

Structured error responses: Your custom exception can return an object (as shown above) rather than just a string message. NestJS will serialize this object as the JSON response. The structure can follow a standard like RFC 7807 (Problem Details) or a custom schema. For instance, one could include fields like title, status, detail, and even an array of sub-errors. In the example above, we included statusCode and message for clarity. An alternative approach is demonstrated by a custom NotFoundError class that includes a title, detail and even an errors array for context. By extending HttpException and supplying a rich response object, you make error messages more informative and relevant to the specific context.

Mapping Prisma Known Errors to HTTP Exceptions

When using Prisma (an ORM/DB client), certain database errors are common and expected (e.g. unique constraint violations, foreign key violations, record not found). By default, if not handled, these will bubble up as unhandled exceptions and NestJS will treat them as internal server errors (HTTP 500). We want to intercept these and translate them into appropriate HTTP responses. This is best done with a global exception filter that specifically catches Prisma errors.

Prisma’s error format: Prisma throws a PrismaClientKnownRequestError for known errors. This exception includes a .code property (like "P2002") that identifies the error type. Some common Prisma error codes and their meaning:

These mappings align with best practices and the default suggestions from the community. For instance, the nestjs-prisma package’s built-in exception filter maps P2002 to 409 Conflict and P2025 to 404 Not Found by default.

Implementing a global Prisma exception filter: We can create a global exception filter that catches all exceptions, checks if it's a Prisma KnownRequestError, and maps it accordingly. This filter will prevent those errors from reaching the default handler (which would return 500). Instead, it will produce a controlled HttpException.

import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, ConflictException, BadRequestException, NotFoundException } from '@nestjs/common';
import { BaseExceptionFilter } from '@nestjs/core';
import { Prisma } from '@prisma/client';  // PrismaClientKnownRequestError is under Prisma namespace

@Catch()  // Catch every exception
export class AllExceptionsFilter extends BaseExceptionFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    // If the exception is a Prisma known client error, map it to an HttpException
    if (exception instanceof Prisma.PrismaClientKnownRequestError) {
      const prismaError = exception;
      switch (prismaError.code) {
        case 'P2002':
          exception = new ConflictException('Unique constraint violation.');
          break;
        case 'P2025':
          exception = new NotFoundException('Record not found.');
          break;
        case 'P2000':
          exception = new BadRequestException('Value too long for field.');
          break;
        case 'P2003':
          exception = new BadRequestException('Invalid reference specified.');
          break;
        // ... handle other Prisma error codes as needed
        default:
          // For unmapped codes, you might choose a generic error:
          exception = new HttpException('Database error', HttpStatus.INTERNAL_SERVER_ERROR);
      }
    }

    // (Optional) Add any other custom exception mapping logic here...
    // e.g., transform validation errors or other library errors if needed.

    // Finally, delegate to Nest's base exception filter for standard processing
    super.catch(exception, host);
  }
}

A few notes on this implementation:

After defining this filter, register it globally so that it applies to all endpoints. You can do this in your main bootstrap function:

// main.ts
const app = await NestFactory.create(AppModule);
app.useGlobalFilters(new AllExceptionsFilter());
// ... listen, etc.

This will ensure any exception thrown in any module of the app goes through our AllExceptionsFilter. (Alternatively, you can provide it in the AppModule with the APP_FILTER token, which accomplishes the same global scope.) Once applied, if you attempt an operation that violates a unique constraint or requests a missing record, the response will be a neatly formatted 409 or 404 instead of a raw 500 error. For example, without handling, a duplicate database entry would trigger a Prisma error and yield a 500; with our filter, the client gets a 409 Conflict with an explanatory message.

Tip: The nestjs-prisma library offers a ready-made PrismaClientExceptionFilter with default mappings. Using such a package can reduce boilerplate. However, implementing it yourself (as above) provides more control and the ability to integrate with other logic (like logging or Sentry) in the same filter.

Centralized Sentry Error Tracking

In a large application, it's critical to log exceptions for debugging and monitoring. Sentry is a popular error tracking service. Instead of sprinkling Sentry.captureException(error) calls in every catch block (which is tedious and error-prone), we can leverage the same global exception filter to handle Sentry logging in one place.

Integrating Sentry in the global filter: We can enrich our AllExceptionsFilter (or create a dedicated global filter) to capture all unhandled exceptions to Sentry. NestJS’s filter mechanism is a convenient hook to send errors to Sentry as they occur. For example:

import * as Sentry from '@sentry/node';

@Catch()
export class AllExceptionsFilter extends BaseExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    // Log every exception to Sentry (adjust filtering as needed)
    Sentry.captureException(exception);
    
    // ... (Prisma mapping logic from above can be here) ...
    super.catch(exception, host);
  }
}

In the code above, the call to Sentry.captureException(exception) will push the exception (along with its stack trace) to Sentry. This happens for any error, whether it’s a handled HttpException or an unexpected error, because our filter catches everything. We then pass the exception to super.catch to continue normal HTTP response processing. This pattern ensures you capture the error once globally, rather than in every service or controller method. A similar approach is shown in a custom Sentry exception filter example: the filter calls Sentry.captureException(exception) inside its catch method, before delegating to the base handler. As the accompanying explanation notes, “Finally, the exception is sent to Sentry for tracking.”.

Avoiding noise: While capturing all exceptions is straightforward, you might not want to send every single error to Sentry. For example, a client requesting a non-existent resource (404) or sending bad data (400) might be a routine occurrence and not indicative of a bug in your system. Flooding Sentry with such expected errors can make it harder to spot real problems. Consider refining the capture logic:

For instance:

if (exception instanceof HttpException) {
  const status = exception.getStatus();
  if (status >= 500) {
    Sentry.captureException(exception);  // Only capture server errors
  }
} else {
  Sentry.captureException(exception);    // It's not an HttpException, definitely capture it
}

You can adjust this logic based on your needs. In many cases, capturing all exceptions (including 4xx) is still useful during development or early testing, and you can dial it back in production.

Adding context: Sentry allows attaching extra context to error reports. In a NestJS filter, you have access to the ArgumentsHost, which can give you the HTTP request details. Using Sentry.configureScope or Sentry.withScope, you can add data like the URL, params, user info, etc., to each error report. For example, one approach captures the request URL, method, headers, body, and even authenticated user info if available, before calling captureException. This enriches the Sentry logs tremendously, making debugging easier. While adding such context is optional, it's a recommended practice for a production setup.

Initialize Sentry once: Ensure you initialize the Sentry SDK at application startup (e.g., in main.ts or in a dedicated provider). Set the DSN, environment, and other options as needed (sample rate, traces, etc.). For example, using Sentry.init({ dsn: 'your-dsn', environment: process.env.NODE_ENV, ... }). Only after initialization will Sentry.captureException actually send data. Also, include the global filter provider (APP_FILTER) or useGlobalFilters registration so that Sentry capturing filter is active. The NestJS official docs also mention a SentryGlobalFilter that can be used if you don’t write a custom one, but our custom approach gives more flexibility.

Capturing original errors: If you are wrapping low-level errors into higher-level exceptions (for example, catching a low-level error and throwing a new HttpException with a sanitized message), be mindful that you might lose the original stack trace. A common scenario is throwing a simplified error for the client, but then Sentry only sees that simplified error, not the root cause. One article described this pitfall – the developer was sending a processed error to Sentry instead of the actual raw error. To avoid this, capture the original error before you transform or rethrow it. In our Prisma example above, we call Sentry.captureException(exception) prior to replacing it with a new HttpException. This way, Sentry logs the true database error (with details like query and parameters) while the client only sees the friendly message. Another approach is to use the cause property of JavaScript errors (Node 16+ allows an options parameter with { cause: originalError } when constructing a new Error), though NestJS HttpException may not natively expose it. In practice, explicitly capturing the original as shown is simplest.

By centralizing Sentry logging in the global filter, you ensure every unhandled error is reported exactly once, making debugging and monitoring far easier than manual logging scattered across the codebase.

Scaling the Pattern in Modular and Monorepo Setups

Designing your exception handling with modularity in mind pays off when your application grows. Here are some architectural recommendations for scaling this pattern:

Use cases and example scenario: Imagine an e-commerce monorepo with separate services for Users, Products, Orders, etc. Each has its own NestJS module or microservice. By using a shared error handling pattern:

By following these patterns, adding new modules or even new applications to the monorepo becomes easier. They can all leverage the established exception system with minimal setup, ensuring that your error handling remains robust, DRY (Don't Repeat Yourself), and easy to reason about across the entire NestJS architecture.

Conclusion

Designing a modular exception handling system in NestJS involves creating a language of errors for your application (via custom exception classes and codes) and a central translator of those errors (via a global exception filter). We achieved this with RacketimeException to encapsulate module-specific error details, and a global filter that maps low-level exceptions (like Prisma errors) to high-level HTTP responses and logs everything to Sentry. The result is a clean separation of concerns:

This setup is highly extensible – you can plug in new error sources (another DB, an external API error) into the filter, and define new error codes in the mapping as your application grows. By centralizing and standardizing error handling, you not only reduce the risk of overlooked errors (no more missing try/catch in some corner), but also make the system’s behavior more predictable. As a best practice, always document your error codes and responses for consumers of your API, and monitor your Sentry (or other logging system) to refine which errors are being thrown and handled. With the outlined approach, your NestJS application will handle exceptions “like a pro” – providing clear client feedback while keeping the engineering team informed of real issues behind the scenes.

Sources: